import swipeEvents from '../../js/swipeEvents.js' import createProps from '../../js/createProps.js' import { recursiveSwitchAriaHidden, setFirstAndLastFocusable, focusin } from '../../js/scopedKeyboardNav.js' import { TIME_FAST, EVENT_MODAL_OPENED, EVENT_MODAL_CLOSED, EVENT_MODAL_OPEN, EVENT_MODAL_CLOSE, EVENT_MODAL_KEY_DOWN, EVENT_SWIPED_DOWN, KEY_ESCAPE } from '../../js/constants.js' import breakpoint from '../../js/breakpoints.js' import { dispatchEvent, readEvent } from '../../js/events.js' const STATE_OPEN = 'isOpen' const SCROLL_LOCK = 'scroll-lock' class Modal extends HTMLElement { constructor() { super() this.classList.add('u-modal') // Init properties this.ariaModal = null this.aaLabelClose = "Close modal" // Events this.boundOpen = this.open.bind(this) this.boundClose = this.close.bind(this) this.boundEscapeClose = this.escapeClose.bind(this) this.boundFocus = this.focus.bind(this) this.boundFocusin = this.focusin.bind(this) } connectedCallback() { this.style.display = 'none' createProps(this, true) this.ariaModal = false this.render() } render() { if (this.hasEvent) { this.unbindEvent() } if (this.slot === '') { this.bindEvent() this.classList.remove(STATE_OPEN) this.isOpen = false return false } // FYI : The role is dialog, not alertdialog (only used for alerts / warnings ...) this.setAttribute('role', 'dialog') // Set the popin aria-hidden by default this.setAttribute('aria-hidden', 'true') // Set the modal style ( DEFAULT / FULL ) if (!this.variant) { this.setAttribute('variant', 'default') } // Be careful to not remove the title div, even if empty, because it holds on purpose the top gradient for scroll effect this.innerHTML = `
${this.heading && `

${this.heading}

`}
${this.slot}
${breakpoint.is.mobile ? '
' : ''}
` this.buttonClose = this.querySelector('.u-close button') this.box = this.querySelector('.u-modal-box') this.section = this.querySelector('.u-inner > section') this.footer = this.querySelector('.u-inner > footer') // Instanciate overlay this.renderOverlay() // Wait for CSS to be downloaded (avoid flashing screen with bad modal skin) setTimeout(() => { this.style.display = 'inherit' }, 100) this.bindEvent() } moveFooter() { const footer = this.section.querySelector("footer") if (footer) { this.footer.remove() this.section.after(footer) this.footer = footer } // Set the first and last focusable elements only after moving footer setFirstAndLastFocusable(this.box) } renderOverlay() { // Instanciate overlay const CustomElement = window.customElements.get("u-overlay") this.overlay = new CustomElement() document.body.appendChild(this.overlay) } addLock() { document.documentElement.classList.add(SCROLL_LOCK) this.overlay?.open() } removeLock() { document.documentElement.classList.remove(SCROLL_LOCK) this.overlay?.close() } switchAriaModal() { this.ariaModal = !this.ariaModal recursiveSwitchAriaHidden(this, this.ariaModal) } async open(e) { // Check if it's right popin to open, if not returns const event = readEvent(e) if (event.id !== this.id) { return } // Transitionend listener is fired multiple times (fore each css property) // so we need to instantiate it only when we open or close and then we remove it on focusin this.addEventListener('transitionend', this.boundFocus) document.addEventListener('focusin', this.boundFocusin) this.openingSource = document.activeElement this.addLock() this.switchAriaModal() this.setAttribute('aria-hidden', 'false') this.isOpen = !this.isOpen await new Promise(resolve => { setTimeout(() => { this.classList.add(STATE_OPEN) resolve() }, TIME_FAST) }) dispatchEvent({ eventName: EVENT_MODAL_OPENED, args: { id: this.id } }) this.moveFooter() } close(e) { if (e) { e.cancelBubble = true if (e.stopPropagation) { e.stopPropagation() } } // Transitionend listener is fired multiple times (fore each css property) // so we need to instantiate it only when we open or close, and then we remove it on focusin this.addEventListener('transitionend', this.boundFocus) document.removeEventListener('focusin', this.boundFocusin) this.removeLock() this.switchAriaModal() this.setAttribute('aria-hidden', 'true') this.isOpen = !this.isOpen this.classList.remove(STATE_OPEN) dispatchEvent({ eventName: EVENT_MODAL_CLOSED, args: { id: this.id } }) } focus() { this.removeEventListener('transitionend', this.boundFocus) if (this.isOpen) { // Set focus on the close button by default is an AA standard this.buttonClose.focus() } else { // Put back the focus on the button that opened the modal this.openingSource.focus() } } focusin(e) { focusin(e, this.box, true) } escapeClose(e) { if (e.key === KEY_ESCAPE && this.isOpen) { this.close(e) } } swipeDown(e) { if (this.isOpen) { this.close(e) } } bindEvent() { this.hasEvent = true this.buttonClose?.addEventListener('click', this.boundClose) window.addEventListener(EVENT_MODAL_OPEN, this.boundOpen) window.addEventListener(EVENT_MODAL_CLOSE, this.boundClose) document.addEventListener(EVENT_MODAL_KEY_DOWN, this.boundEscapeClose) if (breakpoint.is.mobile) { swipeEvents(window, document, this) this.boundSwipeDown = this.swipeDown.bind(this) document.addEventListener(EVENT_SWIPED_DOWN, this.boundSwipeDown) } } unbindEvent() { this.hasEvent = false this.buttonClose?.removeEventListener('click', this.boundClose) window.removeEventListener(EVENT_MODAL_OPEN, this.boundOpen) window.removeEventListener(EVENT_MODAL_CLOSE, this.boundClose) document.removeEventListener(EVENT_MODAL_KEY_DOWN, this.boundEscapeClose) if (this.boundSwipeDown) { document.removeEventListener(EVENT_SWIPED_DOWN, this.boundSwipeDown) this.boundSwipeDown = null } } disconnectedCallback() { if (!this.hasEvent) { return } this.unbindEvent() } } customElements.get('u-modal') || customElements.define('u-modal', Modal) export default Modal